#require File.join(File.dirname(__FILE__), 'exceptions')

require 'xpath'

module Rsel
  # Support functions for Rsel
  module Support

    # Convert the given locator to a format accepted by Selenium. If `locator`
    # starts with `id=`, `name=`, `dom=`, `xpath=` `link=` or `css=`, then the
    # locator is returned unchanged. Otherwise, `locator` is assumed to be a
    # plain string, and a Selenium-style `xpath=` locator is returned, matching
    # HTML elements of the given kind, in the given scope. This allows you to
    # use simple human-readable locator strings, or more specific
    # Selenium-style locators.
    #
    # @param [String] locator
    #   A Selenium-style locator beginning with `id=`, `name=`, `dom=`,
    #   `xpath=`, `link=` or `css=`, or a plain text string that will
    #   automatically match on the `id`, `name`, label, value, or text content
    #   depending on the value of `kind`.
    # @param [String] kind
    #   What kind of locator you're using (link, button, checkbox, field etc.).
    #   This must correspond to a method name in `XPath::HTML`. If you're using
    #   a raw Selenium-style `locator` string, this argument can be omitted.
    # @param [Hash] scope
    #   Keywords to restrict the scope of matching elements. Ignored when
    #   a Selenium-style locator is used. See the {#xpath} documentation for
    #   allowed options.
    #
    def loc(locator, kind='', scope={})
      if locator.empty?
        raise ArgumentError, "locator is required."
      elsif locator =~ /^css=/ && scope != {}
        return csspath(locator, scope)
      elsif locator =~ /^(id=|name=|dom=|xpath=|link=|css=)/
        return locator
      else
        if kind.empty?
          raise ArgumentError, "kind is required for Rsel-style locators"
        else
          return xpath(kind, locator, scope)
        end
      end
    end

    # Return a Selenium-style CSS extending the given CSS with a locator.
    #
    # @param [String] locator
    #   A Selenium-style locator beginning with `css=`.  All other values
    #   are ignored.  (Meaning the locator is returned as passed.)
    # @param [Hash] scope
    #   Keywords to restrict the scope of matching elements
    # @option scope [String] :within
    #   Restrict scope to elements having this id, matching `locator` only if
    #   it's contained within an element with this id.
    # @option scope [String] :in_row
    #   Restrict scope to a table row containing this text, matching `locator`
    #   only if that locator is in the same row as the given text.
    #
    # @example
    #   xpath('link', 'Log in')
    #   xpath('button', 'Submit')
    #   xpath('field', 'First name')
    #   xpath('table_row', ['First', 'Last'])
    #
    def csspath(locator, scope={})
      return locator unless locator =~ /^css=/ && scope != {}
      if scope[:within]
        locator[4,0] = "##{scope[:within]} "
      elsif scope[:in_row]
        locator[4,0] = "tr:contains(\"#{scope[:in_row]}\") "
      end
      return locator
    end

    # Return a Selenium-style xpath generated by calling `XPath::HTML.<kind>`
    # with the given `locator`. If `scope` options are provided, the xpath is
    # modified accordingly, to match only elements in the given scope.
    #
    # @param [String] kind
    #   What kind of locator you're using (link, button, checkbox, field etc.).
    #   This must correspond to a method name in `XPath::HTML`.
    # @param [String] locator
    #   Name, id, value, label or whatever other locators are accepted by
    #   `XPath::HTML.<kind>`
    # @param [Hash] scope
    #   Keywords to restrict the scope of matching elements
    # @option scope [String] :within
    #   Restrict scope to elements having this id, matching `locator` only if
    #   it's contained within an element with this id.
    # @option scope [String] :in_row
    #   Restrict scope to a table row containing this text, matching `locator`
    #   only if that locator is in the same row as the given text.
    #
    # @example
    #   xpath('link', 'Log in')
    #   xpath('button', 'Submit')
    #   xpath('field', 'First name')
    #   xpath('table_row', ['First', 'Last'])
    #
    def xpath(kind, locator, scope={})
      if !XPath::HTML.respond_to?(kind)
        raise ArgumentError, "Unknown kind of locator: '#{kind}'"
      end
      loc_xp = XPath::HTML.send(kind, locator)
      if scope[:within]
        parent = XPath.descendant[XPath.attr(:id).equals(scope[:within])]
        result = apply_scope(parent, loc_xp)
      elsif scope[:in_row]
        row = XPath.descendant(:tr)[XPath.contains(scope[:in_row])]
        result = apply_scope(row, loc_xp)
      else
        result = loc_xp.to_s
      end
      return "xpath=#{result}"
    end


    # Restrict the scope of all XPath expressions in `inner` by prepending
    # `container` to each of them, and return new union expression where
    # `inner` matches only if it's a child of `container`.
    #
    def apply_scope(container, inner)
      scoped_expressions = xpath_expressions(inner).collect do |expr|
        container.child(expr)
      end
      return XPath::Union.new(*scoped_expressions).to_s
    end


    # Return an array of individual Expressions in the given XPath::Union, or
    # just `[union]` if it has no sub-expressions. This is an ugly recursive
    # hack, designed to allow splitting up unions into their constituents for
    # the purpose of modifying them individually and re-combining them.
    #
    # @param [XPath::Union, XPath::Expression] union
    #   The xpath you want to break down into individual expressions
    #
    # @since 0.0.3
    #
    def xpath_expressions(union)
      if union.respond_to?(:expressions)
        return union.expressions.collect do |expr|
          xpath_expressions(expr)
        end.flatten
      else
        return [union]
      end
    end


    # Escape certain characters to generate characters that can't otherwise be
    # used in FitNesse hashtables.
    #
    # * \; becomes :
    # * \' becomes ,
    # * \[ becomes {
    # * \] becomes }
    # * \\ becomes \
    #
    # @since 0.1.1
    #
    def escape_for_hash(text)
      # ((?:\\\\)*) allows any extra pairs of "\"s to be saved.
      text = text.gsub(/(^|[^\\])\\((?:\\\\)*);/, '\1\2:')
      text = text.gsub(/(^|[^\\])\\((?:\\\\)*)'/, '\1\2,')
      text = text.gsub(/(^|[^\\])\\((?:\\\\)*)\[/, '\1\2{')
      text = text.gsub(/(^|[^\\])\\((?:\\\\)*)\]/, '\1\2}')
      text = text.gsub(/\\\\/, '\\')
      return text
    end


    # Normalize the given hash of name => locator mappings.
    # Converts all keys to lowercase and calls {#escape_for_hash} on them.
    #
    # @since 0.1.1
    #
    def normalize_ids(ids)
      ids = {} unless ids.is_a? Hash
      ids.keys.each do |key|
        new_key = escape_for_hash(key.to_s.downcase)
        new_value = escape_for_hash(ids[key])
        ids[new_key] = new_value

        # Delete the old key if necessary
        if new_key != key
          ids.delete(key)
        end
      end
    end


    # Strip HTML tags from the given text. This can be used for converting
    # URLs that FitNesse has marked up back into plain URLs.
    #
    # @param [String] text
    #   Text, possibly including markup, that you want to strip
    #
    # @since 0.1.1
    #
    def strip_tags(text)
      return text.gsub(/<\/?[^>]*>/, '')
    end

    # Return an XPath for any table row containing all strings in `texts`,
    # within the current context.
    #
    # @since 0.1.1
    #
    def xpath_row_containing(texts)
      texts = [texts] if texts.class == String
      conditions = texts.collect do |text|
        "contains(., #{xpath_sanitize(text)})"
      end.join(' and ')
      return "//tr[#{conditions}]"
    end

    # Return the given text string in an XPath-safe form, with
    # any single-quotes escaped by using the XPath `concat`
    # function to combine them with the rest of the text.
    #
    # @example
    #   xpath_sanitize("Bob's")
    #   # => concat('Bob', "'", 's')
    #
    # @since 0.1.1
    #
    def xpath_sanitize(text)
      # If there's nothing to escape, just wrap text in single-quotes
      if !text.include?("'")
        return "'#{text}'"
      else
        result = text.gsub(/'/, %{', "'", '})
        return "concat('#{result}')"
      end
    end

    # Convert a string like "yes", "true", "1", etc.
    # Values currently recognized as true, case-insensitive:
    #
    # * [empty string]
    # * 1
    # * Check
    # * Checked
    # * On
    # * Select
    # * Selected
    # * True
    # * Yes
    #
    # @since 0.1.1
    #
    def string_is_true?(s)
      return /^(?:yes|true|on|(?:check|select)(?:ed)?|1|)$/i === s
    end

    # Compare values like Selenium does, with regexpi? and globs.
    #
    # @param [String] text
    #   A string.
    # @param [String] expected
    #   Another string.  This one may have glob:, regexp:, etc.
    #
    # @since 0.1.1
    #
    def selenium_compare(text, expected)
      if expected.sub!(/^regexp:/, '')
        return /#{expected}/ === text
      elsif expected.sub!(/^regexpi:/, '')
        return /#{expected}/i === text
      elsif expected.sub!(/^exact:/, '')
        return text == expected
      else
        # Default is glob, whether or not glob: is present.
        expected.sub!(/^glob:/, '')
        return File.fnmatch(expected, text)
      end
    end

    # Return `text` with glob markers `*` on each end, unless the text
    # begins with `exact:`, `regexp:`, or `regexpi:`. This effectively
    # allows normal text to match as a "contains" search instead of
    # matching the entire string.
    #
    # @param [String] text
    #   Text to globify
    #
    # @since 0.1.2
    #
    def globify(text)
      if /^(exact|regexpi?):/ === text
        return text
      else
        return text.sub(/^(glob:)?\*?/, '*').sub(/\*?$/, '*')
      end
    end


    # Ensure that a given block gets a result within a timeout.
    #
    # This executes the given block statement once per second, until it returns
    # a value that evaluates as true (meaning anything other than `false` or
    # `nil`), or until the `seconds` timeout is reached. If the block evaluates
    # as true within the timeout, return the block result. Otherwise, return
    # `nil`.
    #
    # If the block never returns a value other than `false` or `nil`, then
    # return `nil`. If the block raises an exception (*any* exception), that's
    # considered a false result, and the block will be retried until a true
    # result is returned, or the `seconds` timeout is reached.
    #
    # @param [Integer, String] seconds
    #   Integer number of seconds to keep retrying the block
    # @param [Block] block
    #   Any block of code that might evaluate to a non-false value
    #
    # @return
    #   Result of the block if it evaluated true-ish within the timeout, nil
    #   if the block always evaluated as false or raised an exception.
    #
    # @since 0.1.2
    #
    # TODO: Return false if the block takes too long to execute (and exceeds
    # the timeout)
    #
    def result_within(seconds, &block)
      (seconds.to_i + 1).times do
        result = yield rescue nil
        return result if result
        sleep 1
      end
      return nil
    end


    # Ensure that a given block fails within a timeout.
    #
    # This is a kind of counterpart to {#result_within}
    #
    # @param [Integer, String] seconds
    #   Integer number of seconds to keep retrying the block
    # @param [Block] block
    #   Any block of code that might evaluate to a false value,
    #   or raise an exception, within the timeout.
    #
    # @return [Boolean]
    #   true if the block failed (returned false/nil or raised an exception)
    #   within the timeout, false if the block never failed within the timeout.
    #
    # @since 0.1.2
    #
    def failed_within(seconds, &block)
      (seconds.to_i + 1).times do
        begin
          result = yield
        rescue
          return true
        else
          return true if !result
        end
        sleep 1
      end
      return false
    end
  end
end

